#!/usr/bin/env python3
# Copyright 2020-2022 Efabless Corporation
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import re
import sys
import json
import click
import queue
import shutil
import logging
import datetime
import threading
import subprocess

from scripts.report.report import Report
from scripts.config.config import ConfigHandler, expand_matrix
from scripts.config.tcl import read_tcl_env
import scripts.utils.utils as utils

configuration_line_rx = re.compile(r"^\s*(\w+)\s*=\s*(.+)\s*$")


def get_design_name(config_file: str) -> str:
    vars = None
    if config_file.endswith(".tcl"):
        vars = read_tcl_env(config_file)
    elif config_file.endswith(".json"):
        vars = json.load(open(config_file))
    else:
        raise ValueError(f"{config_file}: Configuration files must end with .tcl/.json")

    name = vars.get("DESIGN_NAME")
    if name is None:
        raise ValueError(f"{config_file}: No DESIGN_NAME variable")
    return name


@click.command()
@click.option(
    "-c",
    "--config_file",
    default="config",
    help="(Base) configuration filename. Must inside the design directory. If an extension is omitted, both JSON and Tcl will be tried.",
)
@click.option(
    "-m", "--matrix", default=None, help="Path to configuration matrix JSON file"
)
@click.option("-t", "--tag", default=None, help="Tag for the log file")
@click.option(
    "-j", "--threads", default=1, type=int, help="Number of designs in parallel"
)
@click.option(
    "-p",
    "--configuration_parameters",
    default=None,
    help="File containing configuration parameters to append to report: You can also put 'all' to report all possible configurations",
)
@click.option(
    "-e",
    "--excluded_designs",
    default="",
    help="Exclude the following comma,delimited,designs from the run",
)
@click.option(
    "-b", "--benchmark", default=None, help="Benchmark report file to compare with."
)
@click.option(
    "-p",
    "--print_rem",
    default=0,
    help="If provided with a number >0, a list of remaining designs is printed every <print_rem> seconds.",
)
@click.option(
    "--enable_timestamp/--disable_timestamp",
    default=True,
    help="Enables or disables appending the timestamp to the file names and tags.",
)
@click.option(
    "--append_configurations/--dont_append_configurations",
    default=False,
    help="Append configuration parameters provided to the existing default printed configurations",
)
@click.option(
    "--delete/--retain",
    default=False,
    help="Delete the entire run directory upon completion, leaving only the final_report.txt file.",
)
@click.option(
    "--show_output/--hide_output",
    default=False,
    help="Enables showing the output from flow invocations into stdout. Will be forced to be false if more than one design is specified.",
)
@click.argument("designs", nargs=-1)
def cli(
    config_file,
    matrix,
    tag,
    threads,
    configuration_parameters,
    excluded_designs,
    benchmark,
    print_rem,
    enable_timestamp,
    append_configurations,
    delete,
    show_output,
    designs,
):
    """
    Run multiple designs in parallel, for testing or exploration.
    """

    if tag is None:
        if matrix is not None:
            tag = "matrix"
        else:
            tag = "run"

    designs = list(designs)
    excluded_designs = excluded_designs.split(",")

    for excluded_design in excluded_designs:
        if excluded_design in designs:
            designs.remove(excluded_design)

    show_log_output = show_output and (len(designs) == 1) and (matrix is None)

    if print_rem is not None and not show_log_output:
        if float(print_rem) > 0:
            mutex = threading.Lock()
            print_rem_time = float(print_rem)
        else:
            print_rem_time = None
    else:
        print_rem_time = None

    if print_rem_time is not None:
        rem_designs = dict.fromkeys(designs, 1)

    num_workers = threads

    config_name = os.path.splitext(config_file)[0]

    if matrix is not None:
        configuration_matrix_variables = []
        configuration_matrix_str = open(matrix).read()

        for line in configuration_matrix_str.splitlines():
            match = configuration_line_rx.match(line)
            if match is None:
                continue

            if "extra" in line:
                break

            configuration_matrix_variables.append(line[1])

        if len(configuration_matrix_variables) > 0:
            ConfigHandler.update_configuration_values(
                configuration_matrix_variables, True
            )

    if configuration_parameters is not None:
        if configuration_parameters == "all":
            ConfigHandler.update_configuration_values_to_all(append_configurations)
        else:
            try:
                with open(configuration_parameters, "r") as f:
                    configuration_parameters = f.read().split(",")
                    ConfigHandler.update_configuration_values(
                        configuration_parameters, append_configurations
                    )
            except OSError:
                print("Could not open/read file:", configuration_parameters)
                sys.exit()

    store_dir = ""
    report_file_name = ""
    if enable_timestamp:
        timestamp = datetime.datetime.now().strftime("%d_%m_%Y_%H_%M")
        store_dir = f"./regression_results/{tag}_{timestamp}"
        report_file_name = f"{store_dir}/{tag}_{timestamp}"
    else:
        store_dir = f"./regression_results/{tag}"
        report_file_name = f"{store_dir}/{tag}"

    utils.mkdirp(store_dir)

    log = logging.getLogger("log")
    log_formatter = logging.Formatter("%(asctime)s | %(message)s", "%Y-%m-%d %H:%M")
    handler1 = logging.FileHandler(f"{report_file_name}.log", "w")
    handler1.setFormatter(log_formatter)
    log.addHandler(handler1)
    handler2 = logging.StreamHandler()
    handler2.setFormatter(log_formatter)
    log.addHandler(handler2)
    log.setLevel(logging.INFO)

    report_log = logging.getLogger("report_log")
    report_formatter = logging.Formatter("%(message)s")
    report_handler = logging.FileHandler(f"{report_file_name}.csv", "w")
    report_handler.setFormatter(report_formatter)
    report_log.addHandler(report_handler)
    report_log.setLevel(logging.INFO)

    report_log.info(Report.get_header() + "," + ConfigHandler.get_header())

    allow_print_rem_designs = False

    def printRemDesignList():
        t = threading.Timer(print_rem_time, printRemDesignList)
        t.start()
        if allow_print_rem_designs:
            print("Remaining designs (design, # of times): ", rem_designs)
        if len(rem_designs) == 0:
            t.cancel()

    def rmDesignFromPrintList(design):
        if design in rem_designs.keys():
            mutex.acquire()
            try:
                rem_designs[design] -= 1
                if rem_designs[design] == 0:
                    rem_designs.pop(design)
            finally:
                mutex.release()

    if print_rem_time is not None:
        printRemDesignList()
        allow_print_rem_designs = True

    def update(status: str, design: str, message: str = None, error: bool = False):
        width = 10
        str = f"%-7s| %-{width}s" % (
            status,
            design[: width - 3] + "." * 3 if len(design) > width else design,
        )
        if message is not None:
            str += f" | {message}"

        if error:
            log.error(str)
        else:
            log.info(str)

    def resolve_config(conf_file, allow_tcl=False):
        if os.path.isfile(conf_file):
            return conf_file

        if conf_file.endswith(".tcl") or conf_file.endswith(".json"):
            update(
                "ERROR",
                design,
                f"Cannot run: {conf_file} not found",
                error=True,
            )
            return None

        tcl = f"{conf_file}.tcl"
        json = f"{conf_file}.json"
        if os.path.isfile(tcl):
            if allow_tcl:
                return tcl
            else:
                update(
                    "ERROR",
                    design,
                    "Matrix mode is incompatible with .tcl config files",
                    error=True,
                )
                return None
        elif os.path.isfile(json):
            return json
        else:
            update(
                "ERROR",
                design,
                f"Cannot run: No {config_file}.tcl/{config_file}.json found",
                error=True,
            )
            return None

    flow_failure_flag = False
    design_failure_flag = False

    def run_design(designs_queue):
        nonlocal design_failure_flag, flow_failure_flag
        while not designs_queue.empty():
            design, conf_file, design_name = designs_queue.get(timeout=3)  # 3s timeout
            tag = os.path.splitext(os.path.basename(conf_file))[0]
            run_path = utils.get_run_path(design=design, tag=tag)
            update("START", design, tag)
            command = [
                os.getenv("OPENLANE_ENTRY") or "./flow.tcl",
                "-design",
                design,
                "-tag",
                tag,
                "-config_file",
                conf_file,
                "-overwrite",
                "-run_hooks",
            ] + (["-verbose", "1"] if show_log_output else [])
            run_path_relative = os.path.relpath(run_path, ".")
            try:
                shutil.rmtree(run_path_relative)
            except FileNotFoundError:
                pass
            skip_rm_from_rems = False
            try:
                if show_log_output:
                    subprocess.check_call(command)
                else:
                    subprocess.check_output(command, stderr=subprocess.STDOUT)

                update(
                    "SUCCESS",
                    design,
                    "",
                )
            except subprocess.CalledProcessError as e:
                if print_rem_time is not None:
                    rmDesignFromPrintList(design)
                    skip_rm_from_rems = True
                log_path = f"{run_path_relative}/openlane.log"
                if os.path.isfile(log_path):
                    update(
                        "FAIL",
                        design,
                        f"Check {log_path}",
                        error=True,
                    )
                else:
                    update(
                        "FAIL",
                        design,
                        "OpenLane failed to start up:",
                        error=True,
                    )
                    print(e.stdout.decode("utf8"))

                design_failure_flag = True

            if print_rem_time is not None and not skip_rm_from_rems:
                rmDesignFromPrintList(design)

            try:
                params = ConfigHandler.get_config_for_run(None, design, tag)
                update("DONE", design, f"{tag}: Writing report...")

                report_str = Report(design, tag, design_name, params).get_report()
                report_log.info(report_str)

                with open(f"{run_path}/report.csv", "w") as report_file:
                    report_file.write(
                        Report.get_header() + "," + ",".join(params.keys())
                    )
                    report_file.write("\n")
                    report_file.write(report_str)
            except FileNotFoundError:
                pass

            if benchmark is not None:
                try:
                    update("DONE", design, "Comparing with benchmark results...")
                    subprocess.check_output(
                        [
                            "python3",
                            "./scripts/compare_regression_design.py",
                            "--output-report",
                            f"{report_file_name}.rpt.yml",
                            "--benchmark",
                            benchmark,
                            "--design",
                            design,
                            "--run-path",
                            run_path,
                            f"{report_file_name}.csv",
                        ],
                        stderr=subprocess.PIPE,
                    )
                except subprocess.CalledProcessError as e:
                    error_msg = e.stderr.decode("utf8")
                    update(
                        "ERROR",
                        design,
                        f"Failed to compare with benchmark: {error_msg}",
                    )
                    design_failure_flag = True

            if delete:
                try:
                    update("DONE", design, "Deleting run directory...")
                    shutil.rmtree(run_path)
                    update("DONE", design, "Deleted run directory.")
                except FileNotFoundError:
                    pass
                except Exception:
                    update(
                        "ERROR", design, "Failed to delete run directory.", error=True
                    )
                    flow_failure_flag = True

    q = queue.Queue()
    total_runs = 0
    for design in designs:
        base_path = utils.get_design_path(design=design)
        if base_path is None:
            update("ALERT", design, "Not found, skipping", error=True)
            if print_rem_time is not None:
                if design in rem_designs.keys():
                    rem_designs.pop(design)
            continue

        conf_file = os.path.join(base_path, config_file)
        conf_file = resolve_config(conf_file, matrix is None)
        if conf_file is None:
            continue

        try:
            design_name = get_design_name(conf_file)
        except ValueError as e:
            update("ERROR", design, f"Cannot run: {e}", error=True)
            continue

        if matrix is not None:
            config_file_paths = expand_matrix(
                conf_file, matrix, os.path.join(base_path, f"{tag}_config")
            )

            total_runs += len(config_file_paths)
            if print_rem_time is not None:
                rem_designs[design] = total_runs

            for config_name in config_file_paths:
                q.put((design, config_name, design_name))
        else:
            conf_name, conf_ext = os.path.splitext(os.path.basename(conf_file))
            target_config = os.path.join(base_path, f"{tag}_{conf_name}{conf_ext}")
            shutil.copyfile(conf_file, target_config)

            q.put((design, target_config, design_name))

    workers = []
    for i in range(num_workers):
        workers.append(threading.Thread(target=run_design, args=(q,)))
        workers[i].start()

    for i in range(num_workers):
        while workers[i].is_alive():
            workers[i].join(100)
        log.info(f"Exiting thread {i}...")

    log.info("Getting top results...")
    subprocess.check_output(
        [
            "python3",
            "./scripts/report/get_best.py",
            "-i",
            report_handler.baseFilename,
            "-o",
            f"{report_file_name}_best.csv",
        ]
    )

    utils.add_computed_statistics(report_file_name + ".csv")
    utils.add_computed_statistics(report_file_name + "_best.csv")

    if benchmark is not None:
        log.info("Benchmarking...")
        full_benchmark_comp_cmd = [
            "python3",
            "./scripts/compare_regression_reports.py",
            "--no-full-benchmark",
            "--benchmark",
            benchmark,
            "--output-report",
            f"{report_file_name}.rpt",
            "--output-xlsx",
            f"{report_file_name}.rpt.xlsx",
            f"{report_file_name}.csv",
        ]
        subprocess.check_output(full_benchmark_comp_cmd)

    log.info("Done.")

    if design_failure_flag:
        exit(2)
    if flow_failure_flag:
        exit(1)


if __name__ == "__main__":
    cli()
